
昨天結尾說今天看 value 上的另一半。Encoded 與 Encrypted 在 API payload 上只差一個數字,底下卻是三段有先後的處理,而回程走的時候,順序不只是倒著跑一遍。
一套 ERP 的 API 要回答兩個彼此獨立的問題:一次呼叫的內容該處理成什麼樣子,以及誰有資格決定它可以少處理一點。前者是一條固定的管線,後者是一句寫在方法上的宣告。兩件事分開放,只在存取控制那一站被比在一起。
本篇說明:
ApiProtectionLevel 與 PayloadFormat 是兩個不同的東西,存取控制怎麼把它們比在一起aes-cbc-hmac 這個名字底下實際做了哪幾件事Encoded 的 value 送出去是一段 Base64。從一個物件走到那段 Base64,中間發生三段處理,每一段各由一個介面承擔,也各自可以整個換掉:
| 段 | 做什麼 | 現行實作 | 還能換成什麼 |
|---|---|---|---|
| 序列化 | 物件變成 bytes | messagepack |
json |
| 壓縮 | bytes 變短 | gzip |
none |
| 加密 | bytes 變成帶認證碼的 bytes | aes-cbc-hmac |
none |
後兩個名字寫在部署的那份 SystemSettings 裡,第一個由呼叫端逐次宣告在 API payload 上,沒有宣告就是 messagepack,昨天那張「三個組件,兩種決定方式」的表講的就是這件事。可以換掉的是每一段的實作,不是它們的先後。編碼那一支(ApiPayloadTransformer)只有兩行,原始碼把順序寫在行末:
byte[] bytes = serializer.Serialize(payload, type); // Serialize
return ApiServiceOptions.PayloadCompressor.Compress(bytes); // Compress
加密不在這兩行裡。它是外面那一層依格式決定要不要多做的一步,Encoded 停在這裡,Encrypted 再多走一段。
壓縮之所以排在加密之前,是因為壓縮對密文完全無效。一份良好加密的輸出在統計上與隨機資料沒有差別,而壓縮演算法找的正是重複與偏態。拿案例那份訂單的 FormSchema 跑一次就看得出來:
| 順序 | 結果 |
|---|---|
| 序列化 → 壓縮 → 加密 | 壓完剩下四分之一出頭,加密再多加幾十個位元組 |
| 序列化 → 加密 → 壓縮 | 壓完比壓之前還大,最後的體積是前者的三倍以上 |
把順序調換不會讓任何一次呼叫失敗,也不會有任何一道檢查出聲。它只是讓壓縮那一段從此不做事。
回程的順序是反過來的三步:解密、解壓縮、反序列化。但反向真正的重點不在「倒著跑」,在於每一段只看得到通過上一段的 bytes。認證碼的驗證發生在解密之前,解密又在解壓縮之前,所以那個 gzip 解壓器從頭到尾拿不到一段沒有驗過的資料。
解壓器是一個吃任意 bytes 的解析器,把一段改過一個位元的密文直接餵給它,它會照樣開始解析,然後因為讀不出結構丟出例外。這條順序讓它沒有機會走到那裡。
PayloadFormat 的三個值昨天講過。存取控制那一站要看的不是它一個,是它跟另一個 enum 比出來的結果,而那個 enum 有四個值。
| 回答的問題 | 幾個值 | 誰決定 | |
|---|---|---|---|
PayloadFormat |
這一次呼叫實際做了什麼 | 三個 | 呼叫端,寫在 API payload 上 |
ApiProtectionLevel |
這一支方法最低接受什麼 | 四個 | 寫這支方法的人,掛在方法或型別上 |
兩個 enum 有兩個值同名(Encoded 與 Encrypted),很容易被當成同一件事的兩種說法。它們不是:一個記錄事實,一個宣告要求。
四個等級各要求什麼,適合放哪一種方法:
| 等級 | 意思 | 可以用在哪 |
|---|---|---|
Public |
HTTPS 就夠了 | 內容被伺服器看到也無妨的呼叫,例如連通性探測 Ping |
Encoded |
至少要編碼過 | 不想讓人抓包就直接讀出來,但不值得付加密成本的 |
Encrypted |
要加密過 | 內容在伺服器裡也不該是明文的,例如 API 金鑰的管理 |
LocalOnly |
不接受遠端呼叫 | 只該由主機行程自己發起的,例如寫入定義 |
框架自己的方法怎麼分布在這四級上,本身是一個決定:把信任界線畫在 HTTPS 上,只有寫方法的人明確判斷「進了伺服器之後也不能被看到」的那幾支才標 Encrypted。中間那一級現行沒有人宣告,那是格式那一側的落點,不是等級這一側的要求。
畫在 HTTPS 上不代表應用層加密沒有用。HTTPS 只保護資料在網路上跑的那一段,一進到伺服器就解開了,後面經手的每一站看到的都是明文:節點被入侵、記錄與監控把內容留了下來、跨網段或 VPN 上被攔下來重放,這些在 ERP 的實際部署裡都是真的。
線畫在 Public,換到的是沒有 .NET 執行環境的前端不必先實作整條加密管線才呼叫得到第一支方法;代價是走這一級的呼叫,value 進了伺服器之後就看得見,需要那一層的方法得自己標上去。
判準是:實際的格式要大於或等於宣告的等級,多做無妨,少做拒絕。下面這張矩陣講的都是遠端呼叫,而遠端呼叫不管格式做到哪一級,都過不了 LocalOnly:
| 實際格式 \ 宣告等級 | Public |
Encoded |
Encrypted |
LocalOnly |
|---|---|---|---|---|
Plain |
通過 | 拒絕 | 拒絕 | 拒絕 |
Encoded |
通過 | 通過 | 拒絕 | 拒絕 |
Encrypted |
通過 | 通過 | 通過 | 拒絕 |
對著案例的端點打,三種拒絕各帶各的訊息:
Plain 呼叫 System.ListApiKeys → This API requires encoded or encrypted transmission.
Encoded 呼叫 System.ListApiKeys → This API requires encrypted transmission.
Plain 呼叫 System.SaveDefine → This API is restricted to local calls only.
「多做無妨」那一半同樣實測過:拿一個 Encoded 的 API payload 呼叫 Public 的清單查詢,通過,而且回來的也是 Encoded。伺服端不動格式,它照著呼叫端送來的那個值把回應包回去,所以一次往返的兩個方向永遠是同一個格式。
LocalOnly 不是「更嚴格的加密」,它問的是另一件事:這支方法根本不從網路進來。既然不在同一條軸上,它就不跟格式比大小,是單獨判的。Day 17 說過伺服端有幾支方法只接受同行程呼叫,指的就是這一格。它們仍然叫得動,只是不從網路叫。近端呼叫一進這道檢查就被整個放行,連格式都不看,上面那張矩陣管不到它們。
這道檢查能排在解密之前,是因為它要比的兩個值一開始就拿得到:格式是 API payload 上的一個數字,等級掛在型別上、編譯完就固定了。兩邊都不必拆開 value 看一眼。Day 16 說過存取控制排在還原之前、不替沒通過的請求做解密工作,成立的條件就在這裡。
沒有宣告的方法不是「不受限制」,是直接擲例外。找的順序是方法自己、它覆寫的那一支、再到宣告它的型別。代價是漏掉宣告要等有人第一次呼叫才會發現,所以建置期另有一條規則會把這種方法指出來。
aes-cbc-hmac 這個名字底下有幾件事這個名字把三個部分寫在一起:一個對稱加密演算法、一個工作模式、一個訊息認證碼。實作上是四個決定,加解密的主體是 AesCbcHmacCryptor:
==。一般的迴圈會在第一個不同的位元組就回傳,於是「猜對了幾個位元組」變成從外面量得出來的事,攻擊者可以一個位元組一個位元組把認證碼湊出來。固定時間比對那一條在解密那一段就是三行:
byte[] computedHmac = hmac.ComputeHash(dataToVerify);
if (!CryptographicOperations.FixedTimeEquals(hmacBytes, computedHmac))
throw new CryptographicException("HMAC validation failed.");
訊息本身也藏著一個選擇。實測三種不同的破壞方式:改一個位元的密文、改一個位元的認證碼、換一把完全無關的金鑰,回來的都是同一則 HMAC validation failed.。分不出來是對的,能分辨「認證碼不對」與「金鑰不對」本身就是一個可以拿來試探的訊號。
上一節那四個決定全部做對,還是沒有回答一件事:那把 64 個位元組的金鑰是誰給的。框架把這件事跟上一節分在兩層:
| 層 | 位置 | 放什麼 | 舉例 |
|---|---|---|---|
| 密碼學原語 | Bee.Base |
給定輸入算出輸出,沒有業務語意 | AES-CBC-HMAC、RSA、密碼雜湊、金鑰產生器、檔案雜湊 |
| 安全政策 | Bee.Definition |
金鑰從哪裡來、誰能存取、怎麼驗 | 主金鑰來源、金鑰的保護、存取令牌驗證、第二節那個保護等級 |
分界只要問一句話:這段程式知不知道它算的是什麼?上一節那支加密程式只認得 bytes 與金鑰,不知道自己在加密一則 API 呼叫,所以它住在原語那一層,也因此本篇的 payload 與接下來要談的金鑰保護用的是同一支程式。
Day 4 那條啟動相依鏈的結論在這裡直接沿用:主金鑰是鏈條的第一份,它自己不能加密,所以 SystemSettings 只記它在哪裡。框架預設的那一份(SystemSettings.xml)記的是一個環境變數名稱:
<MasterKeySource>
<Type>Environment</Type>
<Value>BEE_MASTER_KEY</Value>
</MasterKeySource>
環境變數是現行的預設,檔案是另一個選項。兩條路各有各的暴露面,選環境變數不是因為比較安全,是因為容器與 secret 管理工具一律以環境變數注入,走檔案要嘛掛一份 volume、要嘛把檔案烤進映像檔。主金鑰不直接拿來加密任何一則呼叫,它的用途是解開設定檔裡其他的金鑰,那些金鑰存的是密文,包住它們的正是 AesCbcHmacCryptor。
Day 13 列過 session 上有哪些屬性,其中一個是這條連線的對稱金鑰,當時就指名了這一篇。它由一個介面提供,而框架給了三種實作:
| 提供者 | 一把金鑰怎麼來 | 撐不撐得住 session 重建 |
|---|---|---|
| 共用式 | 整個部署共用一把 | 撐得住 |
| 推導式(預設) | 由根金鑰與存取令牌推導出來 | 撐得住 |
| 隨機式 | 登入時隨機產生,只活在 session 裡 | 撐不住 |
第三欄決定的是 Day 13 那個從資料庫 seed 重建 session 能不能成立。介面上有一個屬性專門回答它:金鑰救不回來的話,重建出來的 session 會讓使用者看起來還在線上,而每一次加密呼叫都失敗。
推導式的根金鑰預設由主金鑰推出來,設定檔另外指定的話以設定檔為準。選它當預設的代價是同一個存取令牌推出來的永遠是同一把金鑰,不是每次登入都隨機。換到的是這把金鑰不必存、每個節點各自算得出來,而令牌本身是一個猜不到的 GUID、根金鑰不離開伺服器。這一段的順序因此是反直覺的:令牌必須先產生,金鑰才推得出來。
登入時呼叫端產生一對 RSA 金鑰,公鑰寫進登入請求;伺服端建好 session 之後,用那把公鑰把對稱金鑰包起來,跟存取令牌一起回傳。之後每一次要求加密的呼叫都用它。
Day 17 寫過瀏覽器那一頭的例外:那裡產不出 RSA 金鑰對,於是公鑰送的是空字串,伺服端回的也是空字串。降級發生在呼叫端而不是伺服端,要求 Encrypted 的呼叫在手上沒有金鑰時自己退到 Encoded。對照第二節那張矩陣就知道這條退路能走多遠:Encoded 這個格式過得了 Public 與 Encoded 兩級,過不了標了 Encrypted 的那幾支。
案例跟這一篇有關的應用程式碼只有開機那幾行:環境變數沒設就塞一把 demo 主金鑰進去,把定義檔裡的 payload 選項建成元件,然後在啟動框架時允許自動建立。其餘全在定義檔裡:壓縮用 gzip、加密用 aes-cbc-hmac、主金鑰來自 BEE_MASTER_KEY;value 那一格案例沒有另外宣告,走的是預設的 messagepack。
[ApiAccessControl] 在整個案例是零處。訂單那個 BO 覆寫了 GetNewData,覆寫的方法上沒有掛宣告,靠的正是第二節那條「找不到就往它覆寫的那一支找」。
不加密的那個實作只允許在偵錯模式下建出來,而案例的偵錯模式是開著的(Day 18 用過同一個旗標,那裡決定的是錯誤訊息要不要被遮蔽),於是那道檢查在這裡形同不存在,案例仍然選了真正的加密。這道檢查本身還有一個缺口:它只擋得住依名字建實作的那一條路,直接把元件交進去的那一條繞得過它。
真正讓案例的加密不算數的不是那道檢查,是那把寫在原始碼裡的 demo 主金鑰。它旁邊的註解自己就寫著 production 必須在啟動之前用真正的部署機制注入一把。框架判別不出自己現在跑在哪一種環境,硬要在啟動時擋下來會誤殺想跑示範資料的測試環境,所以這件事只寫在註解裡。
一次呼叫的 value 上有三段處理與一句宣告:
這三件全都驗得出來。順序調換過的管線,壓縮率當場就看得出來;等級擋不擋得住,對著端點各打一次就知道;認證碼有沒有蓋在對的位置,改一個位元試一次就有答案。
只有一件不行。那把主金鑰是不是真的只有部署的人知道,從框架裡面看不出來,也量不出來。前面那三件全部做對之後,整套的強度就等於那個環境變數裡放的是什麼,而那件事發生在程式碼之外:真正的防護線是部署的檢查清單。
一條安全管線裡,做得對不對的部分可以被驗證,值不值得信任的部分不能。前者是設計題,寫進機制就結案了;後者每一次部署都要重新回答一次。
明天換一條軸。保護等級管的是一次呼叫的形式,它從頭到尾沒有問過「這個人可以看到哪些資料」。
本系列同步發表於 HackMD,完整目錄